Yes.
It is not always so.
Throughput: the average number of tasks completed per unit time. High throughput is good.
Caching Case Study - 1
Code Judge — service that “judges” your code submission. When you submit a solution for a DSA problem in Scaler, we have to take your code and run it through hundreds of testcases to evaluate whether it is correct or not.
User info / Problem info – a few KBs (SQL database - Amazon RDS)
Testcase data – 1 to 2 GBs (File storage - Amazon S3)
For a problem we run your code through 100 testcases.
Imagine that the problem is about sorting the array.
Each testcase comprises of a large array (N = 106)
Each expected output for each testcases is also a large array (N = 106)
100 testcases * (106 integers / testcase) * (8 bytes / integer)
= 100 * 106 * 8 bytes
= 800 MB
~ 1 GB
This is for the input file.. similar size for the output file.
Scaler has around 3,000 problems
so around 3000 problem * (1GB testcase data / problem)
= 3TB of testcase data
Is this data too large?
Not really - storage is cheap.
3TB is too large to fit completely in RAM. But can easily fit on HDD.
Around 100 - 200 different problems are being solved by the Scaler students on any given day
We’re running multiple batches in parallel. Each batch might be solving the problems of the current (or last) class. The batches also overlap in timelines.
No! Backend services will never access data from the CDN
Absolutely No.
Therefore, we need a cache!
POST /evaluate-submission
fn check_solution(request):
pid = request.solution.problem_id
uid = request.auth_token.uid
user_info, problem_info = fetch_from_SQL(user_id, pid)
if not file_exists(`{pid}_in.txt`): // caching logic
download_from_s3(url=’s3.hld-bucket-com/testcases/{pid}_in.txt’
save_to=’/users/scaler/testcases/{pid}_in.txt’)
download_from_s3(url=’s3.hld-bucket-com/testcases/{pid}_out.txt’
save_to=’/users/scaler/testcases/{pid}_out.txt’)
// this function will save it on the local HDD
// these reads are from the local HDD
inputs = read_file(`/users/scaler/testcases/{pid}_in.txt`)
expected_outputs = read_file(`/users/scaler/testcases/{pid}_out.txt`)
return evaluate(solution.code, inputs, expected_outputs)
N/A - single vs distributed only applies for global caches
Local cache is distributed automatically, because there are multiple app server
When is invalidation applicable? if the data never changes, do we need to invalidate?
Invalidation is only applicable when the data changes.
New problems can be added - but that is not a data updation.
Can the testcase data for an existing problem be updated?
Yes.
Because our problem setters are not Gods.. they can make mistakes.
It is possible that
Extremely rarely. We have good problem setters, and a lot of quality assurance to ensure that bad problems are not created.
But it happens from time to time.
Let’s say for a given problem we might want to update the testcases once a year on average.
1 update / problem / year on average
Can we just ignore this, since it is a rare edgecase?
Absolutely NOT!
We must have the infrastructure & logic in place!
Imagine that we realize that our testcases are bad during a live contest — we can’t say stuff like “oh but I thought that this will be so rare, my cat also thinks the same, so I didn’t implement this…”
So we need to have an invalidation algorithm in place.
We know that different invalidation techniques give us different consistency guarantees.
Q: Is eventual consistency good enough, or do we require immediate consistency?
Q: What does eventual consistency mean in this context? What does it mean to have a stale read?
Imagine that during a contest, we realise that some testcases are wrong. Our problem setters will create new testcases, and upload them to S3. And we announce to all contest participants that the testcases have been updated, please resubmit problem 3.
Eventual Consistency: after updation & announcement, for sometime (next 10 mins) when people resubmit P3, still, the old testcases are used. NOT good enough!
Immediate Consistency: after updation & announcement, any submissions to P3 use the new testcases (new testcases should be effective immediately!)
So now that we know that we require immediate consistency, it is obvious that we should use Write Through.
But, let's explore - just for learning
Every time we download the file from S3, we maintain an expiry time. If the request comes before expiry, we will use these cached testcases, otherwise, we will assume that the file has expired, we will delete the cached testcase, and fetch again from S3
fn check_solution(solution):
pid = solution['problem_id']
user_info, problem_info = fetch_from_rds(pid)
if file_exists(`{pid}_in.txt`):
if read_last_updated_at(`{pid}_in.txt`) < now() - (1 hour):
// file was downloaded more than 1 hour ago — stale!
delete_file(`{pid}_in.txt`) // TTL invalidation
if not file_exists(`{pid}_in.txt`): // caching logic
download_from_s3([`{pid}_in.txt`, `{pid}_out.txt`])
// this function will save it on the local HDD
inputs = read_file(`{pid}_in.txt`) // these reads are from the local HDD
expected_outputs = read_file(`{pid}_out.txt`)
return evaluate(solution.code, inputs, expected_outputs)
There should some sweet spot for TTL.
No! There's absolutely no sweet spot - none of the values are going to work. All of them are bad experience some way or another.
Same as TTL — we will have to decide how frequently the CRON job will run.
Once again, there’s no ideal value
This is stupid — this will lead to data loss. Additionally, we’ve a local cache - we will have to write to 1000 app servers whenever a write comes.
Absolutely not!
Maintaining atomicity across 2 servers is already insanely hard & slow.
Maintaining atomicity across 100s of servers is impossible
this will NOT work!
Instead, we can do the following
fn check_solution(request):
pid = request.solution.problem_id
uid = request.uid
user_info, problem_info = fetch_from_rds(uid, pid)
input_file_name = problem_info.input_file_name // get the version from the SQL DB
output_file_name = problem_info.output_file_name
if not file_exists(input_file_name): // caching logic
download_from_s3([input_file_name, output_file_name])
// this function will save it on the local HDD
inputs = read_file(input_file_name) // these reads are from the local HDD
expected_outputs = read_file(output_file_name)
return evaluate(solution.code, inputs, expected_outputs)
Basically, we're caching the testcases
we're not caching the version id - the version id is fetched everytime from the DB — so version id cannot be stale
Now that we're not updating the testcases at all (we’re not modifying existing files, we're uploading a new version), the testcase are immutable - since they never change, there's not need for invalidation.
Note: basically, the app-server is only doing eviction, not invalidation. The problem setter does invalidation by “invalidating” the old testcase filenames and replacing them with the new testcase file names in the SQL database.
Operating system will automatically maintain the read/write timestamps for all files. We can just use that for LRU eviction.
We can find all the files in the folder — whichever file was least recently used, just delete that to make space.
fn check_solution(solution):
pid = solution['problem_id']
user_info, problem_info = fetch_from_rds(pid)
file_name = problem_info['file_name'] // get the version from the SQL DB
if not file_exists(`{file_name}_in.txt`): // caching logic
if get_folder_size('.') > 100GB:
delete(get_oldest_accessed_at_file('.')) // LRU eviction
download_from_s3([`{file_name}_in.txt`, `{file_name}_out.txt`])
// this function will save it on the local HDD
inputs = read_file(`{file_name}_in.txt`) // these reads are from the local HDD
expected_outputs = read_file(`{file_name}_out.txt`)
return evaluate(solution.code, inputs, expected_outputs)
Ask yourself, “why do we have more than 1 server?”
The LB can just use Round Robin. Each app servers acts independently.
But, routing based on problem_id is a bad design!
NO! We can not multi-thread the code-judge!
Code evaluation is a CPU bound task.
Most programs are I/O bound. They're waiting for some I/O to happen (user keystroke / mouse click, network download, file read, printer access / ...)
CPU are millions of times faster than disks / networks.
99.99% of the time, your CPU is idle.
So, you can do multiple things at the same time by "context switching" the CPU.
Do task 1 - now that task 1 is waiting for some I/O - but instead of waiting, you context switch
start doing task 2 - task 2 will also go for I/O - context switch back to task 1
do task 1 - ...
...
CPU bound tasks (video processing, heavy computation, analytics, machine learning, sha calculation, ...)
context switching will worsen the performance - because the CPU is already busy - if you try to break its loop and get it to multi-task it will slow everything down - thrashing
Moral: only multi-thread/async-io I/O bound processes. Never multi-thread CPU bound processes.
In code judge, evaluating a single request requires ~5 seconds. During those 5 seconds the app server is completely occupied - it cannot handle any other requests.
Therefore, we want requests to go to the next available server - Round Robin routing
Caching Case Study - 2
Leaderboard
Assumptions:
Avg. submissions / participant / problem = 1
Total submissions during contest
= 1 submission / (participant * problem) * 5 problems * 100,000 participants
= 1 submission * 5 * 100,000
= 500,000 submissions during the entire contest
Average number of submissions per second
500,000 submissions / 3 hour
= 500,000 submissions / (3 * 3600 seconds)
= 500,000 submissions / 10,000 seconds
= 50 submissions / second
Number of submissions / second during the start and end of the contest will be higher than the average
Peak Load
= 2x the average load
= 100 submissions / second
Users are submitting their solutions at very high rate (100 submissions per second)
These submissions are being evaluated by the code judge (as discussed in the previous case study)
For each request, the code judge will update the final verdict/score in the SQL db.
Based on this collective data, we need to compute and show the leaderboard.
What contest is running (contests table)
What users are participating in this contest (join b/w users table, contests table and the contest_participants table)
What problems are there in this contest (join b/w problems table, contests table, and the contest_problems table)
What submissions have the users made for this contest (join b/w users table, user_submissions table, problems table, contest_problems table)
H/W: do the LLD for this and try to figure out the basic DB schema for these tables (columns, indexes, f-key constraints, the not-null constraints, …)
We will take all this data
The final sorted list will be our ranklist.
As assumed earlier, we’re getting 500,000 submissions in total during the contest.
if each submission detail is 100 bytes (problem id, score, time taken, verdict, user id, contest id, ...)
total size = (100 bytes / submission) * 500,000 submissions
= 50 MB
The data is not large, but,
because this is heavy compute, lets say it takes us ~5 seconds for us to fetch data & compute this ranklist
Assume that each user views the ranklist 20 times (on average) during the contest (once every 10 mins).
Requests / second
= (20 views / user / 3 hours) * 100,000 users
= (20 views / 3 hours) * 100,000
= 2 million views / 3 hours
= 2 million views / (10,000 seconds)
= 200 views / second
That's stupid.
Because the request rate is very high, and the ranklist computation is heavy, we don't want to compute it again and again.
Hence the need for caching.
Either/And of
A simple JSON file
[
{rank: 1, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] },
{rank: 2, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] },
{rank: 3, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] },
{rank: 4, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] }
]
(100 bytes / user entry) * (10 entries / page)
= 100 bytes * 10 / page
= 1KB / page
We can totally go with a Global cache, because for any request, we will only need to fetch 1KB of data.
(100 bytes / user entry) * (100,000 users)
= 10 MB
this is a tiny amount of data!
Data is tiny, so a single cache server can store it
200 queries / second can easily be handled by a single redis server
A single redis server can easily handle up to 100,000 read requests/sec!
The amount of data transferred over the n/w for each leaderboard page request is only 1KB — which is also very small. So no extreme n/w overhead (we only need 200 Kbps n/w bandwidth for this)
Since the cached data is tiny, the n/w overhead for each request is small, therefore, the ideal cache here is a Single Global Cache server
Note: even though I explicitly said that we can use Redis here, *you* should never do that during interviews (never name a specific technology).
For example, don’t say Kafka or Redis or Postgres..
Instead, say Persistent Message Queue, or in-memory Key-Value Cache, or Relational Database
What will happen if you name a technology?
So what should you say?
Just say.. “We will use a single global cache server here. We need a fast, in-memory key-value cache… something like Redis or Memcache or something else”
If the interviewer says “how will you choose” / “choose one”..
I will research before choosing one.
The most popular solution for caches (and key-value db) by far.
Very fast, in-memory, key-value database
Fast because
In-memory
Key-value
Powerful primitives
Database
It's always good to have. If we can get immediate consistency without any issues, then why not!
When a user make a code submission, their score has changed – the “true” ranklist has changed.
Eventual Consistency would mean that the leaderboard still shows the old (stale) ranks for some time (say 10 mins) even though the true ranks have changed.
No. If the true ranking of the participants has changed, but the changes don't show up in the leaderboard for some time - that's not the end of the world.
That won't cause a bad user experience.
Eventual consistency is good enough. Immediate consistency is not critical.
How frequently does the "true theoretical" rankings change?
With every submission!
Suppose users make 5 submissions on average during the contest
We calculated earlier that the peak load was 100 submissions / second
True theoretical ranklist changes 100 times / second
It takes ~5 seconds to compute the ranklist once
It is impossible to get immediate consistency.
Because by the time we calculate the ranklist, it has already changed 500 times!
Eventual Consistency is good enough!
Invalidate it every
In fact, Scaler invalidates the ranklist every 30 mins. And nobody has complained about it so far.
Both TTL & Write Around provide eventual consistency.
In this case, the cache doesn’t have the ranklist yet..
so any request should return an error — ranklist is not available yet.. it will appear after 10 mins.
Alternatively, you could “warm up” the cache before the contest starts, so that everyone has rank 1 at the start of the contest (or random ranks).
We’ve two types of queries that need to be answered
Key | Value |
contest:3:page:1 | “[ {rank: 1, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] }, {rank: 2, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] }, {rank: 3, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] }, {rank: 4, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] }, ... 10 entries ]” |
contest:3:page:2 | “[ {rank: 11, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] }, {rank: 12, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] }, {rank: 13, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] }, {rank: 14, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] }, ... 10 entries ]” |
... | |
contest:3:page:10000 | “[ { ... }, { ... }, { ... }, { ... }, ... 10 entries ]” |
contest:3:user:377 | {rank: 1, user_id: 377, user_name: ..., problems: [{1: ...}, {...}, ...] } |
contest:3:user:123 | {rank: 50, user_id: 123, user_name: ..., problems: [{1: ...}, {...}, ...] } |
contest:3:user:2573 | {rank: 1232, user_id: 2573, user_name: ..., problems: [{1: ...}, {...}, ...] } |
contest:3:user:58 | {rank: 2, user_id: 58, user_name: ..., problems: [{1: ...}, {...}, ...] } |
... 100,000 entries (one for each participant) | |
Total amount of data in Redis = 10MB + 10MB = 20MB
(because each user's entry is being stored twice (once for page, once for the user) )
Suppose the user (with id=1234) goes to page 23 in the leaderboard
Their request will go to an app server in the Leaderboard Service.
This app server will make 2 reads from redis
pageEntries = redisClient.get(“contest:3:page:1”)
myRank = redisClient.get(“contest:3:user:1234”)
return MakeLeaderboardTable(pageEntries, myRank)
contest:3:institution:IIT-B ⇒ {intitute_name: …, users: [{rank: 1, user_id, …}, {...}, …]}
Low Scale (of data): data that can fit on 1 server (if in RAM: <= 10 GB, if in disk <= 1TB)
High Scale (of data): data that cannot fit on 1 server
20MB of data / contest. There's no need of eviction!
Note that we will have to store this data for every live contest.
Once the contest is over, the ranklist for that contest can't change (no more new submissions) - so the ranklist can just be dumped in the SQL db itself (no need to cache the data because after the contest the number of views for the leaderboard will go down, and since the ranklist doesn’t change, you don’t need to compute anything.)
How many live contest might we run in parallel on any given moment?
Max 10 (usually 1 or 2)
200MB of data in total ⇒ still doesn't require eviction
Eviction algorithm: once the contest ends (after 24 hours), dump the leaderboard data into the SQL db itself.
There's no LB for the cache - the cache is a single Redis server
If the cache server crashes - no data loss (cache doesn't store any "real" data)
We will just restart the cache server, and the cron job will run automatically after 10 mins (or we can force it to run after the cache server has been restarted)